计算机系统 - 体系结构
目录
目录
一、简单 CPU 设计实例(VS-CPU)
1.1 核心组件
一个最简 CPU 至少需要以下部件协同工作:
| 部件 | 全称 | 职责 |
|---|---|---|
| PC | Program Counter | 存放下一条要取的指令地址 |
| IR | Instruction Register | 暂存当前刚取到的指令 |
| MAR | Memory Address Register | 向内存发出要访问的地址 |
| MDR | Memory Data Register | 暂存从内存读到 / 要写入的数据 |
| ALU | Arithmetic Logic Unit | 算术与逻辑运算 |
| CU | Control Unit | 解析指令、发出控制信号驱动各部件 |
| 通用寄存器 | R0, R1 … | 存放操作数和中间结果 |
MAR / MDR 是 CPU 与内存之间的"接口":CPU 想读内存时,先把地址放进 MAR,内存把数据送回 MDR;写内存时反过来,把数据放进 MDR、地址放进 MAR,然后发写信号。
1.2 指令执行周期(Instruction Cycle)
每条指令都经过以下阶段,不断循环:
flowchart LR
F["取指 (Fetch)"] --> D["译码 (Decode)"] --> E["执行 (Execute)"]
E --> F
取指(Fetch)
MAR ← PC:把 PC 中的地址送到 MAR- 内存根据 MAR 取出指令 →
MDR ← Memory[MAR] IR ← MDR:指令存入 IRPC ← PC + 指令长度:PC 自增,指向下一条指令
译码(Decode)
- CU 解析 IR 中的 操作码(Opcode) 与 操作数字段
- 确定需要哪些寄存器 / 内存地址参与运算
- 从寄存器堆读出源操作数
执行(Execute)
- ALU 完成运算(加减、逻辑、移位等)
- 若需访存(
load/store),则再走一次 MAR → MDR 读写内存 - 将结果写回目标寄存器或内存
VS-CPU 的简化之处:取指和执行共用同一组 MAR/MDR(所以取指和执行不能同时访问内存),这也是后面引入流水线时需要解决的核心冲突之一。
1.3 示例:一条 ADD 指令的完整数据通路
假设执行 ADD R0, R1(R0 = R0 + R1):
sequenceDiagram
participant PC
participant MAR
participant MEM as 内存
participant MDR
participant IR
participant CU as 控制器
participant ALU
Note over PC: PC = 0x0004
PC->>MAR: ① MAR ← 0x0004
MAR->>MEM: ② 发出地址
MEM->>MDR: ③ 指令送入 MDR
MDR->>IR: ④ IR ← 指令
Note over PC: ⑤ PC ← 0x0008
IR->>CU: ⑥ CU 译码:ADD R0, R1
CU->>ALU: ⑦ 控制信号:做加法
Note over ALU: ⑧ R0 + R1 → R0
1.4 控制器的状态机(FSM)
控制器(CU)的本质是一个有限状态机:每个时钟周期处于一个状态,根据当前状态和指令内容,决定发出哪些控制信号、跳转到哪个下一状态。
stateDiagram-v2
[*] --> S0_取指
S0_取指 --> S1_译码 : 指令已送入 IR
S1_译码 --> S2_执行 : 操作码解析完成
S2_执行 --> S3_访存 : 若为 load/store
S2_执行 --> S4_写回 : 若为算术指令
S3_访存 --> S4_写回
S4_写回 --> S0_取指 : 下一条指令
状态机驱动一切:CPU 的节拍(时钟)每跳动一次,状态机就推进一步,输出一组控制信号去驱动各个器件。所以 CPU 的执行过程就是状态机不断"转圈"的过程。
1.5 器件控制信号
每个状态下,CU 通过控制信号线告诉各器件"该做什么"。以 VS-CPU 为例:
| 控制信号 | 作用 | 值为 1 时 |
|---|---|---|
| PCout | PC → 总线 | 将 PC 的值送上总线 |
| MARin | 总线 → MAR | MAR 锁存总线上的地址 |
| MemRead | 存储器读 | 内存开始读操作,数据送入 MDR |
| MDRout | MDR → 总线 | 将 MDR 的值送上总线 |
| IRin | 总线 → IR | IR 锁存总线上的指令 |
| PCinc | PC 自增 | PC ← PC + 指令长度 |
| Rin / Rout | 寄存器读写 | 将指定寄存器读出/写入总线 |
| ALUop | ALU 操作类型 | 选择加/减/与/或等运算 |
| MemWrite | 存储器写 | 内存开始写操作 |
取指阶段的控制信号时序
取指过程拆成微操作后,每一步对应一组信号:
| 微操作 | 激活的控制信号 | 说明 |
|---|---|---|
| MAR ← PC | PCout, MARin | PC 送地址到 MAR |
| MDR ← Memory[MAR] | MemRead | 内存读,数据送入 MDR |
| IR ← MDR | MDRout, IRin | MDR 内容存入 IR |
| PC ← PC + 1 | PCinc | PC 指向下一条指令 |
执行阶段示例:ADD R0, R1
| 微操作 | 激活的控制信号 | 说明 |
|---|---|---|
| ALU_A ← R0 | R0out | R0 值送入 ALU 输入端 A |
| ALU_B ← R1 | R1out | R1 值送入 ALU 输入端 B |
| R0 ← ALU_result | ALUop=ADD, R0in | ALU 做加法,结果写回 R0 |
关键理解:一条汇编指令 = 若干微操作(micro-operation)= 若干组控制信号的时序组合。控制器的工作就是把指令"翻译"成这些信号序列。
1.6 控制器的两种实现:硬布线 vs 微程序
上面说的"状态机输出控制信号"只是逻辑模型,硬件上怎么实现这个状态机有两条路线:
| 硬布线控制器 | 微程序控制器(微序列控制器) | |
|---|---|---|
| 实现方式 | 用组合逻辑电路直接产生信号 | 将控制信号编码为微指令,存在控制存储器(CS)中 |
| 速度 | 快(纯电路) | 稍慢(多一次查表) |
| 灵活性 | 差(改指令集要重新布线) | 好(改微程序即可) |
| 典型应用 | RISC(指令简单,电路规模可控) | CISC(指令复杂,硬布线太庞大) |
| 类比 | 把菜谱刻在灶台上 | 把菜谱写在纸上,做菜时翻着看 |
1.7 微程序控制器详解
核心概念
| 术语 | 说明 |
|---|---|
| 微操作 (μop) | 一个最小的硬件动作,如 PCout、MARin |
| 微指令 (μ-instruction) | 一组同时执行的微操作的编码,一个时钟周期执行一条微指令 |
| 微程序 (μ-program) | 实现一条机器指令所需的微指令序列 |
| 控制存储器 (CS / Control Store) | 存放所有微程序的只读存储器(ROM) |
| μPC (微程序计数器) | 指向当前要执行的微指令地址 |
| μIR (微指令寄存器) | 暂存当前正在执行的微指令 |
类比:机器指令就像"一道菜名",微程序就像"这道菜的详细食谱步骤",控制存储器就像"食谱大全"。
微指令的格式
一条微指令通常由两部分组成:
| 字段 | 内容 | 说明 |
|---|---|---|
| 控制字段(Control) | 每一位对应一根控制信号线 | 决定本周期激活哪些微操作 |
| 下地址字段(Next Address) | 下一条微指令的地址 | 决定下一步执行哪条微指令 |
控制字段的编码方式:
水平微代码(Horizontal Microcode)= 直接编码
控制字段中每一位直接对应一根控制信号线,1=激活,0=不激活。
| 特点 | 说明 |
|---|---|
| 微指令宽度 | 很宽(有多少控制信号就有多少位,可达几十~上百位) |
| 并行度 | 高 —— 一条微指令可以同时激活多个不互斥的信号 |
| 译码 | 无需译码,控制字段直接驱动信号线 |
| 速度 | 快 |
| 缺点 | 控制存储器占用空间大(每条微指令都很长) |
直接编码示例(假设 8 个控制信号):
| 微指令 | PCout | MARin | MemRead | MDRout | IRin | PCinc | Rout | ALUop | 下地址 |
|---|---|---|---|---|---|---|---|---|---|
| 取指 ① | 1 | 1 | 0 | 0 | 0 | 0 | 0 | 00 | 0x02 |
| 取指 ② | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 00 | 0x03 |
| 取指 ③ | 0 | 0 | 0 | 1 | 1 | 0 | 0 | 00 | 0x04 |
| 取指 ④ | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 00 | → 译码 |
注意取指 ① 中
PCout和MARin同时为 1:这就是水平微代码的优势——一个周期内可以并行完成"PC 输出到总线"和"MAR 锁存"两个动作。
垂直微代码(Vertical Microcode)= 字段编码
将互斥的控制信号分组,每组用较少的编码位表示,通过译码器还原为实际信号。
为什么可以分组? 因为某些信号天然互斥——同一时刻总线上只能有一个来源输出:
| 信号组(互斥) | 成员 | 编码位数 |
|---|---|---|
| 总线来源 | PCout, MDRout, R0out, R1out(4 选 1) | 2 位 |
| 总线目标 | MARin, IRin, R0in, R1in(4 选 1) | 2 位 |
| 内存操作 | 无, MemRead, MemWrite(3 选 1) | 2 位 |
| ALU 操作 | 无, ADD, SUB, AND, OR(5 选 1) | 3 位 |
对比同一条微指令(取指 ①:MAR ← PC):
| 编码方式 | 表示 | 总位数 |
|---|---|---|
| 水平(直接) | PCout=1, MARin=1, 其余全0 | 8+ 位 |
| 垂直(字段) | 总线来源=00(PC), 总线目标=00(MAR), 内存=00(无), ALU=000(无) | 9 位 |
这个例子里差异不大,但当控制信号有 50~100 根时,垂直编码可以从 100 位压缩到 20~30 位,控制存储器体积大幅缩小。
水平 vs 垂直 总结
| 水平微代码 | 垂直微代码 | |
|---|---|---|
| 微指令宽度 | 宽(= 信号数) | 窄(编码压缩) |
| 并行能力 | 强(直接指定多个信号) | 受限(每组只能选一个) |
| 执行速度 | 快(无需译码) | 稍慢(需译码器展开) |
| 控制存储器大小 | 大 | 小 |
| 编程难度 | 较难(要考虑信号兼容性) | 较易(类似写汇编) |
| 实际应用 | 高性能场景 | 追求紧凑的场景 |
现实中多采用混合编码:对需要高并行度的信号组用直接编码,对互斥明显的信号组用字段编码,兼顾速度与空间。
微程序控制器的工作流程
flowchart TD
A["机器指令送入 IR"] --> B["IR 中的操作码 → 映射逻辑"]
B --> C["生成微程序入口地址 → μPC"]
C --> D["CS[μPC] → μIR(取微指令)"]
D --> E["μIR 控制字段 → 激活控制信号"]
E --> F["各器件执行微操作"]
F --> G{"下地址字段"}
G -->|"顺序执行"| H["μPC ← 下地址"]
H --> D
G -->|"微程序结束"| I["回到取指微程序"]
I --> D
完整执行示例:ADD R0, R1 的微程序
| 步骤 | μPC | 微指令内容 | 激活信号 |
|---|---|---|---|
| 取指 1 | 0x00 | MAR ← PC | PCout, MARin |
| 取指 2 | 0x01 | MDR ← Memory | MemRead |
| 取指 3 | 0x02 | IR ← MDR | MDRout, IRin |
| 取指 4 | 0x03 | PC++ | PCinc |
| 译码 | 0x04 | 操作码 → 查找 ADD 的微程序入口 | — |
| 执行 1 | 0x10 | ALU_A ← R0 | R0out |
| 执行 2 | 0x11 | ALU_B ← R1 | R1out |
| 执行 3 | 0x12 | R0 ← ALU结果,跳回取指 | ALUop=ADD, R0in |
取指微程序是所有指令共享的——无论哪条机器指令,取指阶段都执行同一段微程序(0x00~0x03)。译码之后才分流到各指令自己的微程序。
二、CISC 与 RISC
2.1 两种设计哲学
| CISC | RISC | |
|---|---|---|
| 全称 | Complex Instruction Set Computer | Reduced Instruction Set Computer |
| 核心思路 | 用少量复杂指令完成任务 | 用大量简单指令组合完成任务 |
| 代表架构 | x86 / x86-64 | ARM, RISC-V, MIPS |
2.2 关键差异对比
| 特征 | CISC | RISC |
|---|---|---|
| 指令长度 | 可变长(x86:1~15 字节) | 定长(通常 4 字节 / 32 位) |
| 指令数量 | 多(数百条) | 少(几十~百余条) |
| 单条指令能力 | 强,一条可完成复杂操作 | 弱,每条只做一件简单的事 |
| 访存指令 | 算术指令可直接操作内存 | 只有 load / store 能访问内存(load-store 架构) |
| 寄存器数量 | 较少(x86 只有 8/16 个通用寄存器) | 多(32 个及以上) |
| 执行周期 | 一条指令可能需要多个时钟周期 | 大多数指令 1 个周期完成 |
| 流水线友好度 | 低(变长指令难以对齐) | 高(定长指令天然适合流水线) |
| 代码密度 | 高(一条指令做的事多,程序短) | 低(需要更多指令,程序更长) |
2.3 一个直观的例子
实现 a = a + Memory[addr]:
CISC(x86) — 一条指令搞定:
addl (addr), %eax # eax += Memory[addr],访存 + 运算一步完成
RISC(ARM 风格) — 拆成三步:
ldr r1, [r2] # ① load:r1 = Memory[r2]
add r0, r0, r1 # ② 运算:r0 = r0 + r1
str r0, [r3] # ③ store:Memory[r3] = r0(如果需要写回)
现代趋势:x86 表面上仍是 CISC 指令集,但 CPU 内部会把复杂指令拆成微操作(μops),本质上用 RISC 的方式执行。所以现代 CPU 是"外 CISC 内 RISC"。
2.4 为什么 RISC 更容易做流水线?
定长指令意味着:
- 取指阶段可以在固定时间完成(不用先读一部分再判断指令有多长)
- 每条指令结构统一,译码逻辑更简单
- 大多数指令执行时间一致,各流水级不易堵塞
三、指令执行与流水线
3.1 从单周期到流水线
单周期 CPU
每条指令用一个完整时钟周期完成所有工作(取指 → 译码 → 执行 → 访存 → 写回)。
- 优点:设计简单
- 缺点:时钟周期必须迁就最慢的指令(如
load),浪费大量时间
多周期 CPU
每条指令拆成多个时钟周期,每周期做一步。不同指令可以用不同数量的周期。
- 优点:时钟频率可以更高
- 缺点:同一时刻仍然只有一条指令在执行
流水线 CPU
类似工厂的流水装配线:把指令执行过程拆成多个独立阶段,让多条指令同时处于不同阶段。
3.2 经典五级流水线
| 阶段 | 缩写 | 工作 |
|---|---|---|
| 取指 | IF (Instruction Fetch) | 从内存取出指令,PC+4 |
| 译码 | ID (Instruction Decode) | 解析指令 + 读寄存器 |
| 执行 | EX (Execute) | ALU 运算 / 计算地址 |
| 访存 | MEM (Memory Access) | load/store 读写内存 |
| 写回 | WB (Write Back) | 将结果写入目标寄存器 |
gantt
title 五级流水线时序
dateFormat X
axisFormat %s
section 指令 1
IF : 0, 1
ID : 1, 2
EX : 2, 3
MEM : 3, 4
WB : 4, 5
section 指令 2
IF : 1, 2
ID : 2, 3
EX : 3, 4
MEM : 4, 5
WB : 5, 6
section 指令 3
IF : 2, 3
ID : 3, 4
EX : 4, 5
MEM : 5, 6
WB : 6, 7
section 指令 4
IF : 3, 4
ID : 4, 5
EX : 5, 6
MEM : 6, 7
WB : 7, 8
理想加速比:5 级流水线理论上可以达到 5 倍吞吐量(不是每条指令变快了,而是单位时间完成的指令数增加了)。实际上由于冒险(hazard)的存在,达不到理想值。
3.3 流水线冒险(Hazards)
流水线并非总能顺畅运行,三类冒险会打断流水:
① 结构冒险(Structural Hazard)
原因:多条指令同时需要同一个硬件资源。
典型场景:指令 1 在 MEM 阶段读内存,同时指令 4 在 IF 阶段也要读内存——如果只有一个内存端口,就冲突了。
解决:
- 分离指令存储和数据存储(哈佛架构 / L1 分成 I-Cache 和 D-Cache)
- 复制硬件资源
② 数据冒险(Data Hazard)
原因:后续指令需要用到前一条指令尚未写回的结果。
addl %ebx, %eax # 指令 A:eax = eax + ebx(结果在 WB 阶段才写回)
subl %eax, %ecx # 指令 B:需要用 eax 的新值 ← 但 A 还没写回!
gantt
title 数据冒险示意
dateFormat X
axisFormat %s
section 指令 A
IF : 0, 1
ID : 1, 2
EX : 2, 3
MEM : 3, 4
WB(写回 eax): crit, 4, 5
section 指令 B
IF : 1, 2
ID(读 eax ⚠️ 旧值): crit, 2, 3
EX : 3, 4
MEM : 4, 5
WB : 5, 6
解决:
- 转发 / 旁路(Forwarding / Bypassing):EX 阶段算出结果后,不等写回寄存器,直接"短路"送给下一条指令
- 插入气泡(Stall / Bubble):暂停后续指令,等数据就绪(代价:损失周期)
- 编译器指令重排:把不相关的指令插到中间,避免连续依赖
③ 控制冒险(Control Hazard)
原因:遇到分支 / 跳转指令时,下一条该取哪条指令?流水线已经预取了后续指令,但如果分支跳转了,预取的就白费了。
cmpl %eax, %ebx
je target # 如果相等就跳转 → 流水线已经开始取 je 后面的指令了
addl $1, %ecx # 这条被预取了,但如果跳转发生,它不该执行
解决:
- 分支预测(Branch Prediction):猜测分支方向,猜对就无损,猜错则冲刷(flush)流水线
- 静态预测:总是预测"不跳转",或"向后跳转"(循环通常跳回)
- 动态预测:根据历史记录预测(现代 CPU 准确率 > 95%)
- 延迟槽(Delay Slot):分支后面的一条指令总是执行(MIPS 的做法,编译器负责填入有用指令或
nop)
3.4 流水线性能小结
| 因素 | 影响 |
|---|---|
| 流水线级数 | 级数越多,理论吞吐量越高,但冒险代价也越大 |
| 冒险频率 | 数据依赖 / 分支越多,气泡越多,实际吞吐量下降 |
| 转发 / 预测 | 有效的转发和预测可以大幅减少停顿 |
| CPI | 理想 CPI = 1(每周期完成一条指令),实际 CPI > 1 |
CPI(Cycles Per Instruction):平均每条指令消耗的时钟周期数。流水线的目标是让 CPI 尽可能接近 1;超标量 CPU 甚至可以做到 CPI < 1(每周期完成多条指令)。